05 — 动画

Three.js Journey · Bruno Simon · 难度:简单

05 — 动画 课程海报

介绍

我们创建过一个场景,并在代码末尾渲染了它一次。这已经是很好的进展了,但大多数时候,你会想让自己的作品动起来。

使用 Three.js 时,动画的工作原理就像定格动画。你移动物体,然后做一次渲染。然后你再把物体移动一点,再做一次渲染。如此往复。每次渲染之间物体移动得越多,它们看起来动得就越快。

你所看的屏幕以特定的频率运行。我们称之为帧率(frame rate)。帧率主要取决于屏幕,但电脑本身也有它的限制。大多数屏幕以每秒 60 帧运行。算一算,这意味着大约每 16 毫秒一帧。但有些屏幕可以运行得更快,而当电脑处理东西吃力时,它又会运行得更慢。

我们想要执行一个函数,让它在每一帧移动物体并做渲染,无论帧率是多少。

原生 JavaScript 实现这个的方式是使用 window.requestAnimationFrame(...) 方法。

准备工作

和之前一样,starter 文件里只有位于场景中心的立方体。

使用 requestAnimationFrame

requestAnimationFrame 的主要用途并不是在每一帧运行代码。

requestAnimationFrame 会在下一帧执行你提供的函数。但如果这个函数也在调用 requestAnimationFrame 让同一个函数在下一帧继续执行,那你最终就会让你的函数永远在每一帧执行,这正是我们想要的。

创建一个名为 tick 的函数,并调用它一次。在这个函数里,用 window.requestAnimationFrame(...) 让同一个函数在下一帧执行:

/**
 * Animate
 */
const tick = () =>
{
    console.log('tick')

    window.requestAnimationFrame(tick)
}

tick()

这就行了。你有了一个无限循环。

正如你在控制台看到的,'tick' 在每一帧都被调用。如果你在一台高帧率的电脑上测试这段代码,'tick' 会以更高的频率出现。

现在你可以把 renderer.render(...) 的调用移进这个函数里,并增大立方体的 rotation:

/**
 * Animate
 */
const tick = () =>
{
    // Update objects
    mesh.rotation.y += 0.01

    // Render
    renderer.render(scene, camera)

    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}

tick()
lesson demo video preview

恭喜,你现在有一个 Three.js 动画了。

问题在于,如果你在高帧率的电脑上测试,立方体会转得更快;如果在低帧率的电脑上测试,立方体会转得更慢。

适应帧率

要让动画适应帧率,我们需要知道距离上一次 tick 已经过了多长时间。

首先,我们需要一种测量时间的方式。在原生 JavaScript 中,你可以用 Date.now() 获取当前时间戳:

const time = Date.now()

时间戳对应的是自 1970 年 1 月 1 日(Unix 时间的起点)以来经过的时间。在 JavaScript 中,它的单位是毫秒。

你现在需要做的是用当前时间戳减去上一帧的时间戳,得到我们所说的 deltaTime,并在动画物体时使用这个值:

/**
 * Animate
 */
let time = Date.now()

const tick = () =>
{
		// Time
    const currentTime = Date.now()
    const deltaTime = currentTime - time
    time = currentTime

    // Update objects
    mesh.rotation.y += 0.01 * deltaTime

    // ...
}

tick()
lesson demo video preview

立方体应该会转得更快,因为如果你的屏幕以 60fps 运行,deltaTime 应该在 16 左右,所以你可以通过乘以一个更小的数值来降速。

现在我们把旋转建立在上一次帧间隔经过的时间上,这个旋转速度在每一台屏幕和电脑上都会一样,无论帧率如何。

使用 Clock

虽然这段代码并不复杂,但 Three.js 内置了一个名为 Clock 的解决方案,会帮你处理时间计算。

你只需要实例化一个 Clock 变量,然后使用内置的方法,比如 getElapsedTime()。这个方法会返回自 Clock 创建以来经过的秒数。

你可以用这个值旋转物体:

/**
 * Animate
 */
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update objects
    mesh.rotation.y = elapsedTime

    // ...
}

tick()
lesson demo video preview

你也可以用它在 position 属性上移动东西。如果你把它和 Math.sin(...) 组合起来,能做出相当不错的效果:

/**
 * Animate
 */
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update objects
    mesh.position.x = Math.cos(elapsedTime)
    mesh.position.y = Math.sin(elapsedTime)

    // ...
}

tick()
lesson demo video preview

显然,你可以用这些技巧动画任意 Object3D,比如相机:

/**
 * Animate
 */
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update objects
    camera.position.x = Math.cos(elapsedTime)
    camera.position.y = Math.sin(elapsedTime)
    camera.lookAt(mesh.position)

    // ...
}

tick()
lesson demo video preview

另一个可用的方法是 getDelta(...),但除非你完全清楚 Clock 类的代码里发生了什么,否则不应该用它。用它可能会打乱你的动画,得到意想不到的结果。

使用库

有时候,你希望以某种非常特定的方式动画场景,这需要用到另一个库。动画库数不胜数,但其中非常著名的一个是 GSAP。

要把 GSAP 加到我们的项目里,我们使用 Node.js 自带的依赖管理器 npm。

在终端中(服务器未运行时,或者在同一个文件夹另开一个终端窗口),运行 npm install gsap@3.12

@3.12 是强制指定版本。我们用这个版本是因为它是写这节课时所使用的版本,但如果你想要最新版本,把 @3.12 去掉试一下也行。

GSAP 现在已经在 node_modules/ 文件夹里了,我们可以把它导入 script.js:

import './style.css'
import * as THREE from 'three'
import gsap from 'gsap'

// ...

GSAP 的用法有很多,我们完全可以为它专门开一门课,但这不是本课程的目标。我们只创建一个 tween(补间动画)来测试一下。如果你已经知道怎么用 GSAP,在 Three.js 里用法完全一样。

注释掉之前动画相关的代码,但保留带渲染的 tick 函数。然后你可以用 gsap.to(...) 创建一个我们所说的 tween(从 A 到 B 的动画):

/**
 * Animate
 */
gsap.to(mesh.position, { duration: 1, delay: 1, x: 2 })

const tick = () =>
{
    // Render
    renderer.render(scene, camera)

    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}

tick()
lesson demo video preview

GSAP 内置了 requestAnimationFrame,所以你不需要自己更新动画,但仍然,如果你想看到立方体移动,你需要在每一帧持续渲染你的场景。

选择合适的方案

至于在原生 JS 和动画库之间做选择,取决于你想实现什么。如果你要做一个永远旋转的旋转木马,你根本不需要任何库。但如果想动画一些东西,比如说挥剑,你可能会更喜欢用一个库。